Table users as U {
username varchar [pk]
hashed_password varchar [not null]
full_name varchar [not null]
email varchar [unique, not null]
password_changed_at timestamptz [not null, default: '0001-01-01 00:00:00Z']
created_at timestamptz [not null, default: `now()`]
}
我們在User Table中定義了hashed_password
是一個varchar
,接下我們將學習如何安全地存儲用戶密碼到Database裡。
Bcrypt
在保護用戶數據的過程中,非常重要的一環是確保密碼不以明文形式儲存。在此,我們將探討使用bcrypt雜湊功能來雜湊密碼的方法。
Never Store Naked Passwords
Bcrypt
Hashing Function
Hashing
。Cost Parameter
Random Salt Generation
Salt
”。Final Hash String
Cost
和Salt
組成。使用bcrypt來雜湊密碼是一種安全可靠的方法,它直接將諸如加鹽和密鑰擴展輪這樣的安全元素整合到雜湊值中。
bcrypt參數的適當配置確保了對常見的密碼破解技術(Rainbow table attack的堅固防禦。
Rainbow table attack
在bcrypt雜湊字符串中包含四個主要部分,這些部分共同形成了一個用於數據庫存儲的安全密碼哈希:
使用者登入密碼驗證流程
hashed_password
。naked_password’s
hashing
hashed_password
的cost
和salt
作為參數。naked_password
進行bcrypt雜湊,會產生另一個雜湊值。我們已經透過SQLC
幫我們建立出CreateUser
Function,其中hashed_password
為輸入的參數之一:
type CreateUserParams struct {
Username string `json:"username"`
HashedPassword string `json:"hashed_password"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, createUser,
arg.Username,
arg.HashedPassword,
arg.FullName,
arg.Email,
)
var i User
err := row.Scan(
&i.Username,
&i.HashedPassword,
&i.FullName,
&i.Email,
&i.PasswordChangedAt,
&i.CreatedAt,
)
return i, err
}
此外,在db/sqlc/user_test.go
中的createRandomUser()
單元測試函數中,我們用了一個簡單的"secret
"字串作為hash_password
字段的值,但這並不反映該字段應有的正確值:
func createRandomUser(t *testing.T) User {
arg := CreateUserParams{
Username: util.RandomOwner(),
HashedPassword: "secret",
FullName: util.RandomOwner(),
Email: util.RandomEmail(),
}
...
}
所以接下來我們將更新它們來使用真正的Hash String
創建一個新文件 password.go
在 util
package 中。
定義一個新函數 HashPassword()
,其功能是:
util/password.go
// HashPassword returns the bcrypt hash of the password
func HashPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
return string(hashedPassword), nil
}
Input
:一個類型為字符串的密碼。Return
:hashedPassword
(string
)或者錯誤訊息(failed to hash password
)。bcrypt.GenerateFromPassword()
函數來計算密碼的bcrypt
哈希值。string
轉換到 []byte
切片。bcrypt.DefaultCost
(值是10)作為哈希的cost參數。%w
vs %s
fmt.Errorf
函數用來格式化錯誤訊息。在這個函數中,%w
是一個特殊的格式化指令,它不僅可以將一個錯誤物件格式化為字符串,還可以保留原始的錯誤資訊,使得後續可以使用 errors.Is
或 errors.As
函數來檢查或提取原始錯誤。%s
指令有所不同,因為 %s
只是簡單地將錯誤物件格式化為字符串,而不保留任何原始錯誤資訊。所以,使用 %w
可以提供更多的彈性和功能,特別是當你想保留原始錯誤的上下文資訊時。定義另一函數 CheckPassword()
,其功能和特點是:
func CheckPassword(password, hashedPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
Input
:一個需要檢查的密碼和一個用來比較的hashedPassword
。return
:錯誤訊息(如果有的話)。bcrypt.CompareHashAndPassword()
函數來比較哈希密碼和原始密碼,這兩者都從字符串轉換到 []byte
切片。util/password_test.go
func TestPassword(t *testing.T) {
password := RandomString(6)
hashedPassword, err := HashedPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword)
wrongPassword := RandomString(6)
err = CheckPassword(wrongPassword, hashedPassword)
require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error())
}
util
package 中創建新文件 password_test.go
。TestPassword()
,接收一個 testing.T
對象作為參數。bcrypt.ErrMismatchedHashAndPassword
錯誤。db/sqlc/user_test.go
func createRandomUser(t *testing.T) User {
hashedPassword, err := util.HashPassword(util.RandomString(6))
require.NoError(t, err)
arg := CreateUserParams{
Username: util.RandomOwner(),
HashedPassword: hashedPassword,
FullName: util.RandomOwner(),
Email: util.RandomEmail(),
}
user_test.go
文件中更新函數,使其使用 HashPassword()
函數。createRandomUser()
函數中,用隨機生成的字符串創建一個新的哈希密碼值。util/password_test.go
func TestPassword(t *testing.T) {
password := RandomString(6)
hashedPassword1, err := HashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword1)
err = CheckPassword(password, hashedPassword1)
require.NoError(t, err)
wrongPassword := RandomString(6)
err = CheckPassword(wrongPassword, hashedPassword1)
require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error())
hashedPassword2, err := HashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword2)
require.NotEqual(t, hashedPassword1, hashedPassword2)
}
TestPassword()
函數中進行這方面的測試。require.NotEqual()
來檢查兩次生成的哈希值是否不同。bcrypt.GenerateFromPassword
函數實現const (
majorVersion = '2'
minorVersion = 'a'
maxSaltSize = 16
maxCryptedHashSize = 23
encodedSaltSize = 22
encodedHashSize = 31
minHashSize = 59
)
func GenerateFromPassword(password []byte, cost int) ([]byte, error) {
if len(password) > 72 {
return nil, ErrPasswordTooLong
}
p, err := newFromPassword(password, cost)
if err != nil {
return nil, err
}
return p.Hash(), nil
}
func newFromPassword(password []byte, cost int) (*hashed, error) {
if cost < MinCost {
cost = DefaultCost
}
p := new(hashed)
p.major = majorVersion
p.minor = minorVersion
err := checkCost(cost)
if err != nil {
return nil, err
}
p.cost = cost
unencodedSalt := make([]byte, maxSaltSize)
_, err = io.ReadFull(rand.Reader, unencodedSalt)
if err != nil {
return nil, err
}
p.salt = base64Encode(unencodedSalt)
hash, err := bcrypt(password, p.cost, p.salt)
if err != nil {
return nil, err
}
p.hash = hash
return p, err
}
GenerateFromPassword
函數函數定義:
func GenerateFromPassword(password []byte, cost int) ([]byte, error)
這個函數接受兩個參數:一個字節切片類型的密碼和一個整型的加密成本。它返回一個字節切片(哈希後的密碼)和一個錯誤物件。
檢查密碼長度:
if len(password) > 72 {
return nil, ErrPasswordTooLong
}
如果密碼的長度超過 72 字節,則返回一個錯誤,因為 bcrypt 只能處理最多 72 字節的密碼。
呼叫 newFromPassword
函數:
p, err := newFromPassword(password, cost)
這行程式碼呼叫 newFromPassword
函數來創建一個新的哈希物件。
返回哈希值:
return p.Hash(), nil
如果 newFromPassword
函數成功創建了一個哈希物件,則返回其哈希值和 nil 錯誤。
newFromPassword
函數函數定義:
func newFromPassword(password []byte, cost int) (*hashed, error)
這個函數也接受密碼和加密成本作為參數,並返回一個指向哈希物件的指針和一個錯誤物件。
檢查並設定加密成本:
if cost < MinCost {
cost = DefaultCost
}
如果提供的加密成本低於最小允許值,則將其設定為默認值。
創建新的哈希物件並設定版本和成本:
p := new(hashed)
p.major = majorVersion
p.minor = minorVersion
p.cost = cost
這幾行程式碼創建了一個新的哈希物件並設定了它的版本和加密成本。
生成Random Salt
:
unencodedSalt := make([]byte, maxSaltSize)
_, err = io.ReadFull(rand.Reader, unencodedSalt)
這裡創建了一個新的字節切片來存儲鹽,並使用 rand.Reader
來生成隨機鹽。
加密密碼:
p.salt = base64Encode(unencodedSalt)
hash, err := bcrypt(password, p.cost, p.salt)
這兩行程式碼首先將生成的隨機鹽進行 Base64 編碼,然後使用這個鹽和提供的加密成本來生成密碼的 bcrypt 哈希。
設定哈希值並返回哈希物件:
p.hash = hash
return p, err
如果哈希成功生成,則將其設定為哈希物件的哈希屬性並返回哈希物件。如果在此過程中發生任何錯誤,它將返回一個包含錯誤詳情的錯誤物件。
接下來,我將使用我們已經編寫的 HashPassword()
函數來實現我們Simple Bank
的createUser
API。
在 api 包中創建一個新的檔案 user.go
。
這個 API 會與我們之前實現的創建帳戶 API 非常相似,所以我將從 api/account.go
文件中複製它。然後將這個結構改為 createUserRequest
。它包含以下字段:
Username
: 這是一個必填字段,並且我們不允許它包含任何特殊字符。我將使用由 validator 包提供的 alphanum 標籤,這基本上意味著此字段只應包含 ASCII 字母和數字字符。Password
: 這也是一個必填字段。我們通常不希望密碼太短,因為這會很容易被破解。所以我們使用 min 標籤來說明密碼的長度應至少為6個字符。FullName
: 用戶的全名,這是一個必填字段,但沒有特定要求。Email
: 這是一個非常重要的字段,因為它將是用戶和我們系統之間的主要溝通渠道。我們可以使用 validator 包提供的 email 標籤來確保此字段的值是一個正確的電子郵件地址。type createUserRequest struct {
Username string `json:"username" binding:"required,alphanum"`
Password string `json:"password" binding:"required,min=6"`
FullName string `json:"full_name" binding:"required"`
Email string `json:"email" binding:"required,email"`
}
首先,我們使用 ctx.ShouldBindJSON()
函數將輸入參數從上下文綁定到 createUserRequest
對象中。如果有任何參數無效,我們只返回 400 Bad Request 狀態給客戶端。否則,我們將使用它們構建 db.CreateUserParams
對象。
在這裡,我們需要設置4個字段:Username
、HashedPassword
、Fullname
和 Email
。首先,我們通過調用 util.HashPassword()
函數並傳遞輸入的 request.Password
值來計算 hashedPassword
。如果此函數返回一個非 nil 錯誤,則我們只返回一個 500 Internal Server Error 狀態給客戶端。
func (server *Server) createUser(ctx *gin.Context) {
var req createUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
hashedPassword, err := util.HashPassword(req.Password)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
arg := db.CreateUserParams{
Username: req.Username,
HashedPassword: hashedPassword,
FullName: req.FullName,
Email: req.Email,
}
...
}
然後,我們用這個輸入參數調用 server.store.CreateUser()
。它將返回創建的用戶對象或一個錯誤。
正如在創建帳戶 API 中一樣,如果錯誤不是 nil,則存在一些可能的情景。請記住,在 users 表中,我們有2個唯一的約束:
我們在這個表中沒有外鍵,所以這裡我們只需要保留 unique_violation 代碼名,以便在具有相同用戶名或電子郵件的用戶已經存在的情況下返回403 Forbidden 狀態。
最後,如果沒有錯誤發生,我們只需返回 200 OK 狀態和創建的用戶給客戶端。
func (server *Server) createUser(ctx *gin.Context) {
...
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, user)
}
在 NewServer()
函數中,我將添加一個新的路由,方法是 POST。它的路徑應該是 /users
,它的處理函數是 server.createUser
。
func NewServer(store db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("currency", validCurrency)
}
router.POST("/users", server.createUser)
router.POST("/accounts", server.createAccount)
router.GET("/accounts/:id", server.getAccount)
router.GET("/accounts", server.listAccounts)
router.POST("/transfers", server.createTransfer)
server.router = router
return
server
}
現在打開終端並運行 make server
來啟動服務器。我將使用 Postman 來測試新的 API。選擇 POST 方法並填寫 URL: http://localhost:8080/users
。
建立User
對於請求主體,選擇 raw 並選擇 JSON 格式。我將使用以下 JSON 數據進行測試:
{
"username": "quang3",
"full_name": "Quang Pham",
"email": "quang3@email.com",
"password": "secret"
}
重複的用戶名稱
{
"username": "quang3",
"full_name": "Quang Pham",
"email": "quang3@email.com",
"password": "secret"
}
重複的Email
{
"username": "quang31",
"full_name": "Quang Pham",
"email": "quang3@email.com",
"password": "secret"
}
無效的用戶名稱
{
"username": "quang31@",
"full_name": "Quang Pham",
"email": "quang3@email.com",
"password": "secret"
}
無效的Email
{
"username": "quang31",
"full_name": "Quang Pham",
"email": "quang3email.com",
"password": "secret"
}
過短的密碼
{
"username": "quang31",
"full_name": "Quang Pham",
"email": "quang31@email.com",
"password": "ss"
}
你可以注意到在成功建立User後,hashed_password
值也被返回了,這似乎不對,因為客戶端永遠不需要用這個值做任何事情,並且它可能會引起一些安全問題,因為這段敏感信息正在公共網絡中傳輸。
{
"username": "quang31",
"full_name": "Quang Pham",
"email": "quang31@email.com",
"password": "secret"
}
{
"username": "quang31",
"hashed_password": "$2a$10$LEoa0fN0Se.Nbs54UvlVBurRUFivln9kJJvB9IHf06A/t3BV2qWdm",
"full_name": "Quang Pham",
"email": "quang31@email.com",
"password_changed_at": "0001-01-01T00:00:00Z",
"created_at": "2023-09-20T08:38:55.955744Z"
}
為了解決這個問題,我將在 api/user.go
文件中聲明一個新的 createUserResponse
結構。它將包含 db.User
結構的幾乎所有字段,除了應該刪除的 HashedPassword
字段。
type createUserResponse struct {
Username string `json:"username"`
FullName string `json:"full_name"`
Email string `json:"email"`
PasswordChangedAt time.Time `json:"password_changed_at"`
CreatedAt time.Time `json:"created_at"`
}
然後,在 createUser()
處理函數的末尾,我們構建一個新的 createUserResponse
對象,其中 Username
是 user.Username
,FullName
是 user.FullName
,Email
是 user.Email
,PasswordChangedAt
是 user.PasswordChangedAt
,而 CreatedAt
是 user.CreatedAt
。
func (server *Server) createUser(ctx *gin.Context) {
...
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
rsp := createUserResponse{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
PasswordChangedAt: user.PasswordChangedAt,
CreatedAt: user.CreatedAt,
}
ctx.JSON(http.StatusOK, rsp)
}
最後,我們返回 response 對象而不是 user。完成了!
現在讓我們重新啟動服務器。然後回到 Postman,將用戶名和電子郵件更新為新的值,然後發送請求。
現在它成功了。而且現在響應主體中再也沒有 hashed_password
字段了。完美!
透過這些改進,我們提高了 API 的安全性,並保護了用戶的敏感信息不被外洩。
{
"username": "quang32",
"full_name": "Quang Pham",
"email": "quang32@email.com",
"password": "secret"
}
{
"username": "quang32",
"full_name": "Quang Pham",
"email": "quang32@email.com",
"password_changed_at": "0001-01-01T00:00:00Z",
"created_at": "2023-09-20T08:42:27.401476Z"
}